/*
  $Id: neodom.js 178 2008-06-05 13:48:28Z altblue $
 
  NeoDOM: Prototype extensions package ... and more :-/

  (c) 2005-2008 Marius Feraru <altblue@n0i.net>

  This module is free software; you can redistribute it
  and/or modify it under the same terms as Perl itself.
*/

if (typeof Prototype == 'undefined')
  throw new Error('This package extends Prototype! See http://prototypejs.org/');

Object.extend(Prototype, {

  StyleFragment: '<style[^>]*>([\\S\\s]*?)<\/style>',

  LinkFragment: '<link[^>]*>'

});


// Date Extensions
Object.extend(Date, {

  // [seconds, minutes, hours, days, weeks]
  durationMultipliers: [1, 60, 3600, 86400, 604800],

  fromJSON: function(dt) {
    var m = dt.match(/^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:(Z)|([+-])(\d\d)(?::(\d\d))?)$/);
    if (!m) return null;
    var offset = 0;
    if (m[7] != 'Z') {
      offset += parseInt(m[9],10) * 60;
      offset += parseInt(m[10],10) || 0;
      if (m[8] == '+') offset *= -1;
    }
    m = m.splice(1,6).map(function(s){return parseInt(s,10)});
    m[1] -= 1;
    var milli = Date.UTC.apply(null,m) + offset * 60 * 1000;
    return new Date(milli);
  },

  today: function() {
    var t = new Date();
    t.setHours(0,0,0,0);
    return t;
  },
  tomorrow: function() {
    var t = new Date();
    t.setHours(24,0,0,0);
    return t;
  },
  yesterday: function() {
    var t = new Date();
    t.setHours(-24,0,0,0);
    return t;
  }

});

Object.extend(Date.prototype, {

  clone: function() {
    return new Date(this.getTime());
  },

  date: function() {
    return this.getFullYear()
      + '-' + (this.getMonth() + 1).toPaddedString(2)
      + '-' + this.getDate().toPaddedString(2);
  },

  time: function() {
    return this.getHours().toPaddedString(2)
      + ':' + this.getMinutes().toPaddedString(2)
      + ':' + this.getSeconds().toPaddedString(2);
  },

  datetime: function() {
    return this.date() + ' ' + this.time();
  },

  UTCTime: function() {
    return this.getUTCHours().toPaddedString(2)
      + ':' + this.getUTCMinutes().toPaddedString(2)
      + ':' + this.getUTCSeconds().toPaddedString(2);
  },

  UTCDate: function() {
    return this.getUTCFullYear()
      + '-' + (this.getUTCMonth() + 1).toPaddedString(2)
      + '-' + this.getUTCDate().toPaddedString(2);
  },

  timezone: function() {
    var offset = this.getTimezoneOffset();
    if (!offset) return 'Z';
    return (offset < 0 ? '+' : '-')
      + (offset.abs()/60).floor().toPaddedString(2)
      + ':' + (offset % 60).toPaddedString(2);
  },

  iso8601: function() {
    return this.date() + 'T' + this.time() + this.timezone();
  },

  thisDay: function() {
    var t = this.clone();
    t.setHours(0,0,0,0);
    return t;
  },
  nextDay: function() {
    var t = this.clone();
    t.setHours(24,0,0,0);
    return t;
  },
  previousDay: function() {
    var t = this.clone();
    t.setHours(-24,0,0,0);
    return t;
  },

  daysSinceEpoch: function() {
    return parseInt( this / 86400000, 10);
  },

  fuzzy: function() {
    var daysDiff = this.today().daysSinceEpoch() - Date.today().daysSinceEpoch();
    return ( daysDiff > 1 ? daysDiff : '' )
      + ( daysDiff > 0 ? '+' : '' )
      + this.getHours().toPaddedString(2)
      + ':' + this.getMinutes().toPaddedString(2);
  }

});

Object.extend(Object, {

  isDate: function(o) {
    return !!(o && 'getTimezoneOffset' in o);
  }

});


// Number Extensions
Object.extend(Number.prototype, {

  range: function(end, inc) {
    var start = this.valueOf();
    inc || (inc = 1);
    var reverse = false;
    if (start > end) {
      reverse = true;
      var sw = start;
      start = end;
      end = sw;
    }
    var ret = [];
    for (var i = start; i <= end; i += inc)
      if (reverse) ret.unshift(i);
      else ret.push(i);
    return ret;
  },

  sprintf: function(format) {
    return sprintf(format, this);
  },

  toDuration: function(microPrecision, macroPrecision) {
    if (this < 60) return this.toString();
    var d = [],
        n = (this + 0), // lame clone :)
        m = Date.durationMultipliers,
        i = m.length - (macroPrecision || 0);
    while(i--) {
      x = Math.floor(n/m[i]), n %= m[i];
      if (x || d.length) d.push( x.toPaddedString(2) );
    }
    d = d.join(':')
    if (!microPrecision || !n) return d;
    return d + (n - Math.floor(n)).toString()
                                  .substring(1, microPrecision+2)
                                  .replace(/\.?0+$/,'');
  }

});


// Hash Extensions
Object.extend(Hash.prototype, {
  extract: function (property) {
    return this.map(function(pair){ return pair[1][property] });
  }
});


// String Extensions
Object.extend(String.prototype, {

  uc: String.prototype.toUpperCase,

  lc: String.prototype.toLowerCase,

  clean: function() {
    return this.strip().replace(/[\r\n\s]+/g, ' ');
  },

  qw: function() {
    var s = this.strip();
    return s ? s.split(/\s+/) : [];
  },

  sprintf: function(format) {
    return sprintf(format, this);
  },

  stripStyles: function() {
    return this.replace(new RegExp(Prototype.StyleFragment, 'img'), '');
  },

  stripLinks: function() {
    return this.replace(new RegExp(Prototype.LinkFragment, 'img'), '');
  },

  scrubHTML: function() {
    return this.stripScripts().stripStyles().stripLinks()
                .replace(/\sid="(\S+)"/gi,' id="scrubbed-$1"');
  },

  scrubHTMLBody: function() {
    return this.scrubHTML()
      .replace(/^.*<body[^>]*>/i,'').replace(/<\/body.*$/i,'');
  },

  toInteger: function() {
    return parseInt( this.replace(/[^0-9-]+/g, ''), 10 ) || 0;
  },

  // FIXME: be locale agnostic!
  toFloat: function() {
    return parseFloat( this.replace(/[^0-9.-]+/g, ''), 10 ) || 0;
  },

  toSeconds: (function(){ // this = "weeks:days:hours:minutes:seconds.microseconds"
    var mm = '', i = Date.durationMultipliers.length;
    while (--i) mm += '(?:\\d+:)?';
    var re = new RegExp('^\\.\\d+$|^' + mm + '\\d+(?:\\.\\d+)?$');
    return function() {
      var s = 0;
      if (!re.test(this)) return s;
      var p = this.split(':').reverse(), i = p.length;
      while (i--) s += p[i] * Date.durationMultipliers[i];
      return s;
    };
  })(),

  toTags: function() {
    return this.strip().replace(/(?:\r?\n)+/g, ',').replace(/^[,\s]+/, ''
      ).replace(/[,\s]+$/, '').replace(/(?:,\s*)+/g, ',').replace(/\s+/g, ' '
      ).split(/\s*,\s*/).without('').uniq();
  }

});

function qw(str) { return str.qw() };


// Element Extensions
Element.addMethods({

  hidden: function (el) {
    return !$(el).visible();
  },

  getInnerText: function (el) {
    el = $(el); if (!el) return;
    return ( el.innerText && !window.opera
      ? el.innerText : el.innerHTML.stripScripts().unescapeHTML()
    ).strip();
  },

  xselect: function(element, xpath) {
    return document._getElementsByXPath(xpath, element);
  },

  getCaret: function(ctrl) {
    var CaretPos = { start: 0, end: 0 };
    if (document.selection) { // IE
      ctrl.focus();
      var sel = document.selection.createRange();
      var len = sel.text.length;
      sel.moveStart ('character', -ctrl.value.length);
      CaretPos = {
        start: sel.text.length - len,
        end:   sel.text.length
      };
    } else {
      if (ctrl.selectionStart || ctrl.selectionStart == '0') {
        CaretPos = {
          start: ctrl.selectionStart,
          end:   ctrl.selectionEnd
        };
      }
    }
    return CaretPos;
  },

  setCaret: function(ctrl, caret) {
    if (typeof caret == 'number') {
      caret = { start: caret, end: caret };
    }
    if (ctrl.setSelectionRange) {
      ctrl.setSelectionRange(caret.start, caret.end);
      ctrl.focus();
    } else {
      if (ctrl.createTextRange) {
        var range = ctrl.createTextRange();
        range.collapse(true);
        range.moveEnd('character', caret.end);
        range.moveStart('character', caret.start);
        range.scrollIntoView();
        range.select();
      }
    }
  },

  loading: function (el, tag, text) {
    if (!(el = $(el))) return;
    tag = tag || 'div';
    el.update('<' + tag + ' class="loading">' + (text || 'loading...') + '</' + tag + '>');
    return el;
  },

  processing: function (el, bool) {
    if (!(el = $(el))) return;
    el.disabled = bool;
    el[bool ? 'addClassName' : 'removeClassName']('loading');
    return el;
  }

});

Element.addMethods(['TD', 'TH'], {
  realCellIndex: function(el){
    var idx = el.cellIndex;
    el.previousSiblings().filter(function(el){
      var tag = el.tagName.uc();
      return tag === 'TD' || tag === 'TH'
    }).each(function(td){
      if (td.hasAttribute('colspan'))
        idx += parseInt(td.getAttribute('colspan'),10) - 1;
    });
    return idx;
  }
});

Object.extend(Element, {

  textNode: function(str) {
    return document.createTextNode(str);
  },

  create: function(type, parent, before, attrs) {

    /* allow call "shortcuts" like (type, attrs) */
    if (typeof parent == 'string') {
      parent = $(parent);
    }
    if (parent && typeof parent == 'object') {
      if (typeof parent.childNodes == 'undefined') {
        attrs = parent;
        parent = before = null;
      } else {
        if (typeof before == 'string') {
          before = $(before);
        }
        if (before && typeof before == 'object' && typeof before.childNodes == 'undefined') {
          attrs = before;
          before = null;
        }
      }
    } else {
      parent = null;
    }
    var el = new Element(type,attrs);

    /* add element to current document? */
    if (parent) {
      try { /* IE croaks on null before */
        parent.insertBefore(el, before);
      } catch (e) {
        parent.appendChild(el);
      }
    }

    return el;
  }

});

$x = document._getElementsByXPath;
$T = Element.create;
$TT = Element.textNode;


// Window extensions
if (typeof Window != 'undefined') {
  Object.extend(Window.prototype, {
    updateSize: function() {
      var w = 5, h = 5;
      this.content.immediateDescendants().each(function(el) {
        w += parseInt(el.getWidth(),  10);
        h += parseInt(el.getHeight(), 10);
      });
      this.setSize(w, h, false);
      // bullshit, won't take care of the scrollbars :(
      // this.setSize(this.content.scrollWidth, this.content.scrollHeight, false);
      return this;
    },
    // FIXME: make it fit inside the viewport
    updatePosition: function() {
    }
  });
}


/* FIXME: Who's the original author?! */
function sprintf() {
  if (!arguments || arguments.length < 1 || !RegExp) {
    return;
  }
  var str = arguments[0];
  var retstr = '';
  var re = /([^%]*)%('.|0|\x20)?(-)?(\d+)?(\.\d+)?(%|b|c|d|u|f|o|s|x|X)(.*)/;
  var a = b = [], numSubstitutions = 0, numMatches = 0;
  while ( a = re.exec(str) ) {
    var leftpart = a[1], pPad = a[2], pJustify = a[3], pMinLength = a[4];
    var pPrecision = a[5], pType = a[6], rightPart = a[7];
    numMatches++;
    if (pType == '%') {
      subst = '%';
    } else {
      numSubstitutions++;
      if (numSubstitutions >= arguments.length) {
        throw 'sprintf error: Not enough function arguments ('
              + (arguments.length - 1)
              + ', excluding the string)\r\n'
              + 'for the number of substitution parameters in string ('
              + numSubstitutions + ' so far).';
      }
      var param = arguments[numSubstitutions];

      var pad = '';
      if (pPad && pPad.substr(0,1) == "'") {
        pad = leftpart.substr(1,1);
      } else {
        if (pPad) {
          pad = pPad;
        }
      }

      var justifyRight = true;
      if (pJustify && pJustify === "-") {
        justifyRight = false;
      }

      var minLength = -1;
      if (pMinLength) {
        minLength = parseInt(pMinLength);
      }

      var precision = -1;
      if (pPrecision && pType == 'f') {
        precision = parseInt( pPrecision.substring(1) );
      }
      var subst = param;
      switch (pType) {
        case 'b': subst = parseInt(param).toString(2); break;
        case 'c': subst = String.fromCharCode(parseInt(param)); break;
        case 'd': subst = parseInt(param) ? parseInt(param) : 0; break;
        case 'u': subst = Math.abs(param); break;
        case 'o': subst = parseInt(param).toString(8); break;
        case 's': subst = param; break;
        case 'x': subst = ('' + parseInt(param).toString(16)).toLowerCase(); break;
        case 'X': subst = ('' + parseInt(param).toString(16)).toUpperCase(); break;
        case 'f': if (precision > -1) {
                    subst = Math.round( parseFloat(param) * Math.pow(10, precision) )
                            / Math.pow(10, precision);
                    var dec = subst.toString().replace(/^\d+\./, '');
                    var padRight = precision - dec.length;
                    if (padRight > 0 ) {
                      var arrTmp = new Array(padRight + 1);
                      subst = subst.toString() + arrTmp.join('0');
                    }
                  } else {
                    subst = parseFloat(param);
                  }
                  break;
      };
      var padLeft = minLength - subst.toString().length;
      if (padLeft > 0) {
        var arrTmp = new Array(padLeft+1);
        var padding = arrTmp.join(pad?pad:" ");
      } else {
        var padding = "";
      }
    }
    str = rightPart;
    retstr += leftpart + padding + subst;
  }
  return retstr + str;
};


/* Textareas which are automatically scaled ;-) */
function ElasticTextarea(el, opt) {
  this.dimension = function() {
    var rows = this.element.value.split('\n');
    var cols = 1;
    for (var i=0, len = rows.length; i < len; i++)
      if (cols < rows[i].length)
        cols = rows[i].length;
    return [cols, rows.length];
  };
  this.resize = function() {
    var d = this.dimension();
    this.element.setStyle({'overflow-x': d[0] > this.maxX ? 'scroll' : 'hidden'});
    this.element.setStyle({'overflow-y': d[1] > this.maxY ? 'scroll' : 'hidden'});
    this.element.cols = d[0] < this.minX ? this.minX : d[0] > this.maxX ? this.maxX : d[0];
    this.element.rows = d[1] < this.minY ? this.minY : d[1] > this.maxY ? this.maxY : d[1];
  };
  this.monitor = function(ev) {
    setTimeout(this.resize.bind(this), 10);
    return true;
  };

  el.setAttribute('wrap', 'off');
  this.element = el;
  opt = opt || {};
  var dim = this.dimension();
  this.minX = opt.minX || el.cols || dim[0];
  this.minY = opt.minY || el.rows || dim[1];
  if (opt.maxX) {
    this.maxX = opt.maxX;
  } else {
    var x = document.viewport.getWidth() - el.cumulativeOffset()[0];
    var r = 9.5; // FIXME: find a way to compute px_width_per_char
    this.maxX = parseInt( x / r, 10 );
  }
  if (opt.maxY) {
    this.maxY = opt.maxY;
  } else {
    var x = document.viewport.getHeight() - el.cumulativeOffset()[1];
    var r = 9.5; // FIXME: find a way to compute px_width_per_char
    this.maxY = parseInt( x / r, 10 );
  }

  if (this.minX > this.maxX) this.minX = this.maxX;
  if (this.minY > this.maxY) this.minY = this.maxY;

  this.element.observe('keypress', this.monitor.bindAsEventListener(this));
  this.resize();
  return this;
};

/* Input[type="text"] which are automatically scaled ;-) */
function ElasticInput(el, opt) {
  this.resize = function() {
    var x = this.element.value.toString().length + 1;
    this.element.size = x < this.minX ? this.minX : x > this.maxX ? this.maxX : x;
  };
  this.monitor = function(ev) {
    setTimeout(this.resize.bind(this), 10);
    return true;
  };

  this.element = el;
  opt = opt || {};
  this.minX = opt.minX || el.size || (el.value.length + 1);
  if (opt.maxX) {
    this.maxX = opt.maxX;
  } else {
    if (el.maxLength && el.maxLength > 0) {
      this.maxX = el.maxLength;
    } else {
      var x = document.viewport.getWidth() - el.cumulativeOffset()[0];
      var r = 9.5; // FIXME: find a way to compute px_width_per_char
      this.maxX = parseInt( x / r, 10 );
    }
  }
  if (this.minX > this.maxX) {
    this.minX = this.maxX;
  }
  this.element.observe('keypress', this.monitor.bindAsEventListener(this));
  this.resize();
  return this;
};

/* MouseMove goes BEFORE MouseOver in Opera 9.1 and IE 7 :( */
var Rating = function(el, opt) {
  opt = opt || {};
  el = $(el);
  if (!el) {
    throw "Cannot initialize Rating: invalid base element!";
  }
  if (!opt.rating) {
    opt.rating = el.className.replace(/^.*?rating-(\d+).*$/, '$1') || 0;
  }
  this.rating  = opt.rating;
  this.afterClick = opt.afterClick || function(){return true};
  this.build(el, opt);

  this.onMouseMoveListener = this.onMouseMove.bindAsEventListener(this);
  this.onMouseOverListener = this.onMouseOver.bindAsEventListener(this);
  this.onMouseOutListener  = this.onMouseOut.bindAsEventListener(this);
  this.onClickListener     = this.onClick.bindAsEventListener(this);
  this.selector.observe('mousemove', this.onMouseMoveListener);
  this.selector.observe('mouseover', this.onMouseOverListener);
  this.selector.observe('mouseout',  this.onMouseOutListener);
  this.selector.observe('click',     this.onClickListener);

  return this;
};

Object.extend(Rating.prototype, {
  build: function (el, opt) {
    this.element = $T('em', el.up(), el, {className: 'rating rating-' + this.rating});
    el.remove();
    this.label = $T('span', this.element, {className: 'label'}).update((opt.label || 'Rating:') + ' ');
    this.selector = $T('span', this.element, {className: 'selector'}).update('&#8203;');
    this.rate = $T('span', this.element, {className: 'rating'}).update(this.rating);
  },

  getClickRating: function(ev) {
    return Math.ceil(100 * (Event.pointerX(ev) - this.offset) / this.width / 20);
  },

  update: function (rating) {
    this.rating = rating;
    this.rate.innerHTML = rating;
    this.element.className = 'rating rating-' + rating;
  },

  onMouseOver: function (ev) {
    if (this._over) return;  // for Opera and IE :(
    this._over = true;
    this.originalRating = this.rating;
    this.width   = parseInt( this.selector.getStyle('width'), 10 );
    this.offset  = this.selector.cumulativeOffset()[0];
  },

  onMouseOut: function (ev) {
    this._over = false;
    this.update(this.originalRating);
  },

  onMouseMove: function (ev) {
    if (!this._over) this.onMouseOver(ev);  // for Opera :(
    var rating = this.getClickRating(ev);
    if (this.rating !== rating) this.update(rating);
  },

  onClick: function (ev) {
    var rating = this.getClickRating(ev);
    if ( this.afterClick(rating, this) ) {
      this.originalRating = rating;
      this.update(rating);
    }
  }
});

/* a very basic "tree" widget */
Tree = Class.create();
Tree.prototype = {
  initialize: function (root, o) {
    o = o || {};
    this.branchTag  = o.branchTag  || 'ul';
    this.leafTag    = o.leafTag    || 'li';
    this.onCollapse = o.onCollapse || function() {};
    this.onExpand   = o.onExpand   || function() {};
    this.root = $(root);
    if (!this.root || this.root.tagName.lc() !== this.branchTag)
      return false;
    this.setup(this.root);
    this.root.observe('click', this.clickHandler.bindAsEventListener(this));
  },
  setup: function (ul) {
    var li = $(ul).down(this.leafTag);
    while (li) {
      var branch = $(li).down(this.branchTag);
      li.addClassName('tree-' + (branch ? 'branch' : 'leaf'));
      if (branch)
        this.setup(branch);
      li = li.next(this.leafTag);
    }
  },
  clickHandler: function (ev) {
    var li = Event.element(ev);
    if (li.tagName.lc() !== this.leafTag || !li.hasClassName('tree-branch'))
      return true;
    if (li.hasClassName('tree-collapsed')) {
      li.removeClassName('tree-collapsed');
      this.onExpand(li);
    } else {
      li.addClassName('tree-collapsed');
      this.onCollapse(li);
    }
  }
};
